Skip to content

feat(mail): send outbound replies, minting reply tokens (HT-15) - #12

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-15-send-outbound
Jul 10, 2026
Merged

feat(mail): send outbound replies, minting reply tokens (HT-15)#12
zaridan merged 2 commits into
mainfrom
feat/ht-15-send-outbound

Conversation

@zaridan

@zaridan zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Closes HT-15. The fifth mail-engine increment — send — which closes the loop: parse → thread → store → send. sendReply mints the signed reply token into the outbound Message-ID, so a future customer reply threads back through decideThreading (HT-13) + the store (HT-14). A round-trip test proves the whole loop end to end.

What's here

  • src/providers/email-sender.ts — the EmailSender provider seam. Contract: transmit the engine-set Message-ID verbatim or you're unusable (threading depends on it).
  • src/mail/send.tssendReply: app-generates the outbound thread UUID, mints the token from it, persists the outbound thread as an outbox item (delivery_status='pending'), sends, then marks sent/failed. Persist→send→mark ordering so a crash never reports a false sent; retries reuse the same id/Message-ID, never re-mint.
  • Migration 002 + storedelivery_status with a direction-tied CHECK, explicit-id inserts, setThreadDeliveryStatus.
  • specs/mail/sending.md — the token lifecycle + outbox contract.

Design + review

The id/token circularity (the outbound Message-ID must embed the thread's own id, but the id is the row's PK) was worked out with Codex up front (app-generate the UUID before insert), then the implementation went through two Codex adversarial rounds on this crypto/threading path. Fixes that landed from its findings:

  • preserve the original send error if the failure-mark also throws (AggregateError) rather than swapping one for the other;
  • the delivery_status CHECK is a cross-column direction↔status invariant with an explicit IS NOT NULL guard — a CHECK passes on NULL, so the naive form still admitted an outbound row with a NULL status (caught by a test);
  • setThreadDeliveryStatus is scoped to direction='outbound' and RETURNING-guarded to throw on a zero-row (wrong/deleted/inbound) target;
  • migration 002 backfills preexisting outbound rows before adding the constraint, so it upgrades a non-fresh 001 database instead of failing (covered by a throughId-staged upgrade test).

Deferred (with tickets)

  • Idempotency + delivery worker → HT-16. This increment's sendReply is synchronous with no dedup key, so it must not be wired behind a retrying caller until HT-16 (documented in send.ts).
  • Adapter wire-level Message-ID contract tests — required of the first real adapter (spec §4); there are no real adapters yet, only the interface + a fake.

Testing

Against real in-memory PGlite + a fake EmailSender: happy path (verbatim Message-ID), the round-trip (minted id → inbound In-Reply-TodecideThreading appends to the right conversation/thread), send-failure marks failed, both-errors AggregateError, refusals (deleted/missing), the cross-column CHECK across all five cases, the direction-scoped status guard, and the 001→002 upgrade. 144 tests pass; typecheck + Biome clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added outbound reply sending with consistent conversation threading and stable idempotency.
    • Implemented per-thread delivery tracking (pending, sent, failed) with direction-scoped updates.
    • Added strict preservation of email reply headers (Message-ID, In-Reply-To, References) from engine to provider.
  • Documentation
    • Added an authoritative spec for the outbound message lifecycle and reply-token handling.
  • Bug Fixes
    • Missing/deleted conversations now safely refuse sending without invoking the provider.
    • Improved failure reporting by marking failed deliveries and surfacing combined send/record errors.
  • Tests
    • Expanded coverage for migration behavior, delivery-status invariants, and reply sending outcomes.

Closes the mail-engine loop: parse → thread → store → SEND. sendReply
mints the signed reply token (HT-12) into the outbound Message-ID, so a
future customer reply threads back via decideThreading (HT-13) + the store
(HT-14). Proven end-to-end by a round-trip test.

- src/providers/email-sender.ts — EmailSender provider seam. Contract: the
  provider MUST transmit the engine-set Message-ID verbatim (threading
  depends on it); a provider that can't is unusable.
- src/mail/send.ts — sendReply: app-generates the outbound thread UUID,
  mints the token from it, persists the outbound thread as an outbox item
  (delivery_status='pending'), sends, then marks sent/failed. Persist→send
  →mark ordering so a crash never reports a false 'sent'; retries reuse the
  same id/Message-ID, never re-mint (specs/mail/sending.md §3).
- db migration 002 + store: delivery_status with a direction-tied CHECK;
  explicit-id inserts; setThreadDeliveryStatus.
- specs/mail/sending.md — the token lifecycle + outbox contract.

Reviewed by Codex (crypto/threading path, per standing rule) across two
adversarial rounds. Fixes landed from its findings:
- preserve the original send error if the failure-mark also throws
  (AggregateError), never swap one for the other;
- the delivery_status CHECK is a cross-column direction↔status invariant,
  with an explicit IS NOT NULL guard (a CHECK passes on NULL, so the naive
  form still admitted an outbound row with NULL status — caught by a test);
- setThreadDeliveryStatus is scoped to outbound rows and RETURNING-guarded
  to throw on a zero-row (wrong/deleted/inbound) target;
- migration 002 backfills preexisting outbound rows before adding the
  constraint, so it upgrades a non-fresh 001 database instead of failing
  (covered by a throughId-staged upgrade test).

Deferred with tickets: idempotency/delivery-worker (HT-16); adapter
wire-level Message-ID contract tests (first real adapter). sendReply must
not be wired behind a retrying caller until HT-16.

144 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 12378cbf-726a-48c1-a5e3-c9d1c00208ee

📥 Commits

Reviewing files that changed from the base of the PR and between 9680738 and 7ddcef5.

📒 Files selected for processing (3)
  • specs/mail/sending.md
  • src/mail/send.test.ts
  • src/mail/send.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • specs/mail/sending.md
  • src/mail/send.test.ts
  • src/mail/send.ts

📝 Walkthrough

Walkthrough

Adds the outbound email provider contract, migration-backed thread delivery states, conversation-store support, and a synchronous sendReply flow that persists, sends, and records delivery outcomes while preserving threading identifiers.

Changes

Outbound reply delivery

Layer / File(s) Summary
Delivery status migration and invariants
src/db/migrate.ts, src/db/migrate.test.ts
Adds migration 002 with delivery-status constraints, outbound backfill to pending, partial migration support, and corresponding tests.
Thread delivery state storage
src/store/conversations.ts, src/store/conversations.test.ts
Persists direction-aware delivery status, exposes outbound status updates, and verifies update and rejection behavior.
Outbound email provider contract
src/providers/email-sender.ts, src/providers/index.ts, src/providers/README.md
Defines outbound email payload and sender interfaces requiring verbatim threading headers, and exports and documents the provider boundary.
Reply sending flow
src/mail/send.ts, src/mail/send.test.ts, specs/mail/sending.md
Implements token/thread creation, persist-send-mark ordering, refusal outcomes, failure aggregation, threading round trips, and the outbound sending specification.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant sendReply
  participant ConversationStore
  participant EmailSender
  Caller->>sendReply: Submit reply
  sendReply->>ConversationStore: Persist outbound thread as pending
  ConversationStore-->>sendReply: Return threadId
  sendReply->>EmailSender: Send engine-generated Message-ID
  EmailSender-->>sendReply: Return success or failure
  sendReply->>ConversationStore: Mark sent or failed
  sendReply-->>Caller: Return delivery result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding outbound reply sending and reply-token minting for HT-15.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-15-send-outbound

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/db/migrate.ts (1)

99-102: 🧹 Nitpick | 🔵 Trivial

Consider non-blocking constraint validation if threads grows large on real Postgres.

ADD CONSTRAINT ... CHECK validates every existing row while holding an ACCESS EXCLUSIVE lock, blocking concurrent reads/writes for the scan duration. This is a non-issue for PGlite and small tables today, but for a large threads table on a live Postgres deployment you may later want ADD CONSTRAINT ... NOT VALID followed by a separate VALIDATE CONSTRAINT (which takes only a SHARE UPDATE EXCLUSIVE lock) to avoid a write-blocking migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/migrate.ts` around lines 99 - 102, Update the migration’s
threads_delivery_status_by_direction constraint creation to use ADD CONSTRAINT
... NOT VALID, then add a separate VALIDATE CONSTRAINT statement so existing
rows are checked with reduced locking on live Postgres deployments.
src/mail/send.test.ts (2)

116-120: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise the verbatim References contract.

The happy-path test verifies Message-ID and In-Reply-To forwarding but never supplies or asserts references. Add an ordered References fixture and assert the sender receives it unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/send.test.ts` around lines 116 - 120, Update the happy-path test
around the sender assertions to provide an ordered References fixture in the
inbound message or send input, then assert sender.sent[0].references matches
that fixture exactly and in the same order, alongside the existing messageId and
inReplyTo checks.

160-181: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cover failure while marking a successful send.

The suite covers provider failure and send-plus-failed-mark aggregation, but not the path where sender.send() resolves and setThreadDeliveryStatus(..., 'sent') rejects. Add this case to lock down the pending-state and error behavior before retry logic is introduced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/send.test.ts` around lines 160 - 181, Add a test alongside the
existing sendReply failure cases that uses a sender whose send() resolves
successfully while store.setThreadDeliveryStatus(..., 'sent') rejects. Assert
sendReply rejects with the marking error and verify the outbound thread remains
in pending delivery status, covering the successful-send/failed-mark path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/mail/sending.md`:
- Around line 100-101: The documentation incorrectly states that no token is
minted when a conversation is missing or deleted. Update the refusal statement
in the mail-sending specification to clarify that the reply token is minted
first, then discarded when appendThread rejects the conversation; only
persistence and sending are skipped.

In `@src/mail/send.ts`:
- Line 63: Update the EmailSender type import in send.ts to import from the
providers barrel module instead of the individual email-sender.js file,
following the module import convention documented in src/providers/README.md.

---

Nitpick comments:
In `@src/db/migrate.ts`:
- Around line 99-102: Update the migration’s
threads_delivery_status_by_direction constraint creation to use ADD CONSTRAINT
... NOT VALID, then add a separate VALIDATE CONSTRAINT statement so existing
rows are checked with reduced locking on live Postgres deployments.

In `@src/mail/send.test.ts`:
- Around line 116-120: Update the happy-path test around the sender assertions
to provide an ordered References fixture in the inbound message or send input,
then assert sender.sent[0].references matches that fixture exactly and in the
same order, alongside the existing messageId and inReplyTo checks.
- Around line 160-181: Add a test alongside the existing sendReply failure cases
that uses a sender whose send() resolves successfully while
store.setThreadDeliveryStatus(..., 'sent') rejects. Assert sendReply rejects
with the marking error and verify the outbound thread remains in pending
delivery status, covering the successful-send/failed-mark path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c8ccc4c-23c5-47b1-aede-a729d012aa39

📥 Commits

Reviewing files that changed from the base of the PR and between 44a6280 and 9680738.

📒 Files selected for processing (10)
  • specs/mail/sending.md
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/mail/send.test.ts
  • src/mail/send.ts
  • src/providers/README.md
  • src/providers/email-sender.ts
  • src/providers/index.ts
  • src/store/conversations.test.ts
  • src/store/conversations.ts

Comment thread specs/mail/sending.md Outdated
Comment thread src/mail/send.ts Outdated
…usal wording (HT-15)

Address CodeRabbit on #12:
- send.ts / send.test.ts: import EmailSender/OutboundEmail from
  src/providers (the barrel) not the individual provider file, per
  src/providers/README.md (Major).
- specs/mail/sending.md §5: a refused (missing/deleted) conversation mints
  the token first and then discards it — only persistence and sending are
  skipped; "nothing is minted" was inaccurate (Minor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant